延續上一篇的話題,我們來看看這段程式碼:
console.log(a);
var a;
function a(){
console.log("JavaScript!")
}
如果你已經把它放進 chrome 執行過,那你會知道,它最後印出的是 ƒ a(){ console.log("JavaScript!") }
,識別字 a
儲存的內容是函式 a
而不是變數 a
。
以上是傳入 chrome 105.0.5195.127 的結果,如果是跑 Node.js 就只會顯示為
[Function: a]
,其他瀏覽器也可能顯示不同內容。
所以是後來宣告的變數覆蓋了前面嗎?
這裡來測試看看:
console.log(a);
function a(){
console.log("JavaScript!")
}
var a;
答案一樣是 ƒ a(){ ... }
。
到這裡感覺有點思路了......沒錯,在提升時,如果遇到相同識別字,函式宣告優先於變數宣告,也就是前者會覆蓋後者,但後者並不會覆蓋前者。
除此之外,函式宣告和變數宣告有什麼不同呢?來看看以下例子:
console.log(foo) // undefined
var foo = "Java"
bar() // JavaScript
function bar(){
console.log("JavaScript")
}
按照前面變數提升的邏輯,程式執行 bar()
的時候,應該要顯示 undefined
才對,因為識別字宣告在編譯時期被提升了,而其他部分會留在原地......但在函式身上顯然不是這麼做的。
關於這點,在文章開頭其實有點小小的提示。我們回到這段代碼:
console.log(a);
var a;
function a(){
console.log("JavaScript!")
}
如果使用 chrome 執行,得到的答案是:
ƒ a(){
console.log("JavaScript!")
}
沒錯,程式打印出了函式 a
的完整內容,也就是說,函式宣告在編譯時期是把整個函式塊存進記憶體,體現出來的結果就是整段函式宣告被「提升」了,也就是這樣:
var a;
function a(){
console.log("JavaScript!")
}
console.log(a);
另外函式宣告會覆蓋變數宣告,所以 var a
等同無作用的程式碼,這就是函式宣告的秘密!
這裡同時可以注意到的另一點是:函式內部的作用域,直到函式被調用之前都不會解析。
在編譯時期,程式只是把整塊函式搬進記憶體中做了「提升」,並不理會函式內部的宣告。畢竟函式本身的作用域是封閉的,是否在編譯時期處理內部宣告,對外部作用域並沒有影響,如果最後這個函式根本沒用到,那提前處理完全是白費工。
在之前討論函式作用域的文章中,有做過函式宣告與函式表達式的比較,這兩者雖然都定義了函式,但在提升時的表現卻大不相同,這裡就讓我們檢視一下:
funDeclaration(); // 我是一個函式宣告
funExpression(); // funExpression is not a function
function funDeclaration() {
console.log("我是一個函式宣告");
}
var funExpression = function () {
console.log("我是一個函式表達式")
}
行內函式表達式的本質是將一個函式物件賦值給變數,從以上程式碼可以看到,將兩者都在宣告前呼叫,funDeclaration
由於擁有函式提升,所以能夠正常執行,但 funExpression
在第二行被呼叫時,仍處於被提升後預設值為 undefined
的狀態,還未執行賦值,根本無法作為函式調用。
好吧,或許你已經猜到了,參數其實並沒有「提升」的現象,它一開始就位於作用域的最前面,根本不需要再升。
但是,參數也同樣是廣義變數的一種,在編譯時期就會被放進記憶體中,那麼當它與函式宣告或變數宣告衝突時會發生什麼事?
這裡來跑個實際的例子:
function foo(bar) {
console.log(bar);
var bar = 5;
console.log(bar);
}
foo("Hello");
程式最後印出了 Hello
和 5
,因此不難猜到,在同個作用域中,參數的優先級大於變數宣告。
那再加入函式呢?
function foo(bar) {
console.log(bar);
var bar = 5;
function bar() { }
console.log(bar);
}
foo("Hello");
程式的執行結果,第一個 bar
印出 function bar(){}
,第二個印出 5
。
在這裡,函式宣告的優先級又大於參數,從以上可以知道,這三者的優先順序是這樣的:
函式宣告 > 參數 > 變數宣告
因為詳細解說實際執行內容會衍伸得太複雜,想了解作用域解析的詳細執行內容,可以參考這篇和這篇文章,這裡簡單總結解析作用域識別字的步驟:
以上又能夠總結出兩條簡單的原則:
這些規則確定了作用域解析的最終結果,在 JS 編譯完畢,準備開始執行之前,作用域內的所有變數會以這樣的規則存放於記憶體中。
那麼,現在來看個實際例子:
var foo = "global";
function doThings(foo) {
console.log("foo1:", foo);
function foo(foo) {
console.log("foo2:", foo);
var foo = "inner";
console.log("foo3:", foo);
}
foo("from doThings");
var foo = "doThings";
console.log("foo4:", foo);
}
doThings("from global");
console.log("foo5:", foo);
以上的程式碼會打印出什麼呢?公布答案前,這裡留一段防雷線,保留思考時間。
.
.
.
.
.
.
.
.
.
.
答案如下(使用 Node 16):
foo1: [Function: foo]
foo2: from doThings
foo3: inner
foo4: doThings
foo5: global
這裡來仔細看下它們分別發生了什麼:
function doThings
的作用域,找到 doThings
作用域內的三個 foo
宣告:參數、函式、變數。函式 foo
覆蓋了其他兩者,最後輸出函式 foo
。function foo
的作用域,找到參數 foo
,找到變數宣告 foo
,參數優先於變數,foo2 打印出 from doThings
。接下去執行 foo = "inner";
,foo3 打印出 inner
。doThings
作用域,在 doThings
作用域內有兩個 foo
宣告,函式 foo
覆蓋了變數 foo
,此時 foo
的內容是函式。來到執行時期,執行到 foo = "doThings";
時 foo
的內容被覆蓋為 doThings
,最後打印出 doThings
。foo
,foo
被賦值為字串 global
,打印出 global
(由於每一層作用域都有宣告 var
變數,內部的 LHS 被遮蔽了,所以並沒有影響到最外層的 foo
)。前面已經說明過,所謂的提升,實際上是 JS 在編譯時期就處理好所有宣告,將所有變數事先存入記憶體,並確定每個作用域的存取規則。
所以說,為什麼要有這樣的規定?函式宣告又一定要優先於變數宣告不可嗎?或者說,到底為什麼需要提升呢?
首先關於優先序最高的函式提升,創作者給出的答案是:
提升函式宣告可以讓函式得以在宣告之前調用,這解決了互相遞歸的問題。
因為如果必定要宣告後才能調用,那互相遞歸的函式就需要達成「彼此都宣告在對方之前」這點才能做到。但如果在最一開始就把函式都提升了,那就沒有這個問題了。
這就是函式宣告提升的原因,至於變數宣告的提升......就有點尷尬了。
總結來說,它算是提升函式宣告時不小心跟著提升的,是不經意(unintended)導致的結果(所以在 ES6 時新增了補丁 let
)。
當然,我們也可以說有了變數宣告的提升,在實際執行的時候就能夠省去這個步驟.......但根據作者的說法,這並不是最初想要達成的結果,而是提升函式的副作用。
以下摘錄 JS 創作者本人 Brendan Eich 的 twitter 原文:
"function hoisting allows top-down program decomposition, 'let rec' for free, call before declare; var hoisting tagged along."
"var hoisting was thus unintended consequence of function hoisting, no block scope, JS as a 1995 rush job. ES6 'let' may help."
──BrendanEich Oct 15, 2014
總結來講......既然有了 let
和 const
,就讓我們忘掉 var
這段歷史吧。